go generate 完全指南
争做团队核心程序员,关注「幽鬼」
开发人员有很强的自动化重复性任务的倾向,这也适用于编写代码。因此,元编程(metaprogramming)的主题是一个开发和研究的热门领域,可以追溯到 1960 年代的 Lisp。元编程中一个特别有用的领域是代码生成(code-generation)。支持宏的语言内置了此功能;其他语言扩展了现有功能以支持这一点(例如 C++模板元编程[1])。
虽然 Go 没有宏或其他形式的元编程,但它是一种实用语言,它包含官方工具链支持的代码生成。
自从 Go 1.4[2] 引入 go generate
命令后,它一直广泛应用于 Go 生态系统。Go 项目本身在很多地方都依赖于 go generate
;我将在后面的帖子中快速概述这些用例。
01 基础知识
让我们从一些术语开始。go generate
工作方式主要由三个参与者之间协调进行的:
Generator:是由 go generate
调用的程序或脚本。在任何给定的项目中,可以调用多个生成器,可以多次调用单个生成器等。Magic comments:是 .go
文件中以特殊方式格式化的注释,用于指定调用哪个生成器以及如何调用。任何以文本//go:generate
行开头的注释都是合法的。go generate
: 是 Go 工具,它读取 Go 源文件、查找和解析 magic comments 并运行指定的生成器。
需要强调的是,以上是 Go 为代码生成提供的自动化的全部范围。对于其他任何事情,开发人员可以自由使用适合他们的任何工作流程。例如,go generate
应该始终由开发人员手动运行;它永远不会自动调用(比如不会作为 go build
的一部分)。此外,由于我们通常使用 Go 将二进制文件发送给用户或执行环境,因此很容易理解 go generate
仅在开发期间运行(可能就在运行 go build
之前);Go 程序的用户不会知道哪部分代码是生成的以及如何生成的。(实际上,很多时候会在生成的文件开头加上注释,这是生成的,请别手动修改。)
这也适用于生成 module;go generate
不会运行导入包的生成器。因此,当一个项目发布时,生成的代码应该与其余代码一起 checked 和分发。
02 一个简单的例子
学习最好是动手做;为此,我创建了几个简单的 Go 项目,它们将帮助我说明这篇文章中解释的主题。第一个是samplegentool[3],一个基本的 Go 工具,用于模拟生成器。
这是它的完整源代码:
package main
import (
"fmt"
"os"
)
func main() {
fmt.Printf("Running %s go on %s\n", os.Args[0], os.Getenv("GOFILE"))
cwd, err := os.Getwd()
if err != nil {
panic(err)
}
fmt.Printf(" cwd = %s\n", cwd)
fmt.Printf(" os.Args = %#v\n", os.Args)
for _, ev := range []string{"GOARCH", "GOOS", "GOFILE", "GOLINE", "GOPACKAGE", "DOLLAR"} {
fmt.Println(" ", ev, "=", os.Getenv(ev))
}
}
这个工具不读任何代码,也不写任何代码;它所做的只是报告它是如何被调用的。我们很快就会了解细节。首先我们看另一个项目 - mymod[4]。这是一个示例 Go 模块,包含 3 个文件,分为两个包:
$ tree
.
├── anotherfile.go
├── go.mod
├── mymod.go
└── mypack
└── mypack.go
这些文件的内容只是填充物;重要的是 go:generate
这个神奇的注释。让我们以mypack/mypack.go
中的那个为例:
//go:generate samplegentool arg1 "multiword arg"
我们看到它调用带有一些参数的 samplegentool
。为了使这个调用起作用,应该在 PATH
的某个地方能找到 samplegentool
。这可以通过在 samplegentool
项目运行 go build
来完成,以生成二进制,然后设置 PATH
。现在,如果我们在 mymod
项目的根目录中运行 go generate ./...
,我们将看到如下内容:
$ go generate ./...
Running samplegentool go on anotherfile.go
cwd = /tmp/mymod
os.Args = []string{"samplegentool", "arg1", "arg2", "arg3", "arg4"}
GOARCH = amd64
GOOS = linux
GOFILE = anotherfile.go
GOLINE = 1
GOPACKAGE = mymod
DOLLAR = $
Running samplegentool go on mymod.go
cwd = /tmp/mymod
os.Args = []string{"samplegentool", "arg1", "arg2", "-flag"}
GOARCH = amd64
GOOS = linux
GOFILE = mymod.go
GOLINE = 3
GOPACKAGE = mymod
DOLLAR = $
Running samplegentool go on mypack.go
cwd = /tmp/mymod/mypack
os.Args = []string{"samplegentool", "arg1", "multiword arg"}
GOARCH = amd64
GOOS = linux
GOFILE = mypack.go
GOLINE = 3
GOPACKAGE = mypack
DOLLAR = $
首先,注意 samplegentool
在它出现在 magic comment 中的每个文件上被调用;这包括子目录,因为我们 使用 ./...
模式运行 go generate
。这对于在不同地方有很多生成器的大型项目来说真的很方便。
输出中有很多有趣的东西;让我们一行一行地剖析它:
cwd
报告调用samplegentool
的工作目录。这始终是找到带有 magic 注释的文件的目录;这由go generate
保证,并让生成器知道它在目录树中的位置。os.Args
报告传递给生成器的命令行参数。正如上面的输出所示,这包括 flag 以及用引号括起来的多词参数。传递给生成器的环境变量被打印出来;有关这些的完整解释,请参阅 官方文档[5]。这里最有趣的环境变量是 GOFILE
,它指向在其中找到 magic 注释的文件名(此路径是相对于工作目录的),而GOPACKAGE
告诉生成器,此文件属于哪个包。
03 generators(生成器) 能做什么?
现在我们已经很好地了解了 go generate
是如何调用生成器的,那么它们能做什么呢?事实上他们可以做任何我们想做的事情。毕竟,生成器是计算机程序。如前所述,生成的文件通常也会放入到源代码中,因此生成器可能只需要很少次运行。在许多项目中,开发人员不会像我在上面的示例中那样从根运行 go generate ./...
;相反,他们只会根据需要在特定目录中运行特定的生成器。
在下一节中,我将深入介绍一个非常流行的生成器 — stringer
工具。同时,以下是 Go 项目本身使用生成器执行的一些任务(这不是完整列表;所有用途都可以通过在 Go 源代码树中 grepping go:generate
找到):
gob
包使用生成器生成重复的辅助函数用于编码/解码数据。math/bits
包使用生成器为其提供的某些位操作生成快速查找表。个别 crypto
包使用生成器为某些操作生成散列函数混洗模式和重复的汇编代码。某些 crypto
包还使用生成器从特定的 HTTP URL 获取证书。显然,这些不是为了经常运行而设计的...net/http
使用生成器来生成各种 HTTP 常量。Go 运行时的源代码中有几个有趣的生成器,例如为各种任务生成汇编代码,为数学运算生成查找表等。 Go 编译器实现使用生成器为 IR 节点生成重复的类型和方法。
此外,标准库中至少有两个地方使用生成器来实现类似泛型的功能,其中几乎重复的代码是从不同类型的现有代码中生成的,比如 sort
和 suffixarray
包。
04 深挖生成器 stringer
Go 项目中最常用的生成器之一是stringer[6] — 一种自动为类型创建 String()
方法的工具,以便它们实现 fmt.Stringer
接口。它最常用于为枚举生成文本表示。
我们看标准库math.big
包中的一个例子;具体来说是 RoundingMode[7] 类型,其定义如下:
type RoundingMode byte
const (
ToNearestEven RoundingMode = iota
ToNearestAway
ToZero
AwayFromZero
ToNegativeInf
ToPositiveInf
)
至少在 Go 1.18 之前,这是一个惯用的 Go 枚举;为了使这些枚举值的名称可打印,我们需要为这种类型实现一个 String()
方法,这会使用 switch
语句,枚举每个值及其字符串表示。这是一项非常重复的工作,stringer
工具正好派上用场。
我在一个小示例模块中[8]复制了 RoundingMode
类型及其值, 以便我们可以更轻松地试验生成器。让我们在文件中添加适当的 magic 注释:
//go:generate stringer -type=RoundingMode
我们将快速讨论 stringer
接受的 flag。确保先安装了它:
$ go install golang.org/x/tools/cmd/stringer@latest
现在我们可以运行 go generate
;因为在示例项目中,带有 magic 注释的文件位于一个子包中,所以我将从模块根目录运行它:
$ go generate ./...
如果一切设置正确,此命令成功完成后不会有任何输出。查看项目内容,会发现生成了一个名为roundingmode_string.go
的文件,内容如下:
// Code generated by "stringer -type=RoundingMode"; DO NOT EDIT.package floatimport "strconv"func _() { // An "invalid array index" compiler error signifies that the constant values have changed. // Re-run the stringer command to generate them again. var x [1]struct{} _ = x[ToNearestEven-0] _ = x[ToNearestAway-1] _ = x[ToZero-2] _ = x[AwayFromZero-3] _ = x[ToNegativeInf-4] _ = x[ToPositiveInf-5]}const _RoundingMode_name = "ToNearestEvenToNearestAwayToZeroAwayFromZeroToNegativeInfToPositiveInf"var _RoundingMode_index = [...]uint8{0, 13, 26, 32, 44, 57, 70}func (i RoundingMode) String() string { if i >= RoundingMode(len(_RoundingMode_index)-1) { return "RoundingMode(" + strconv.FormatInt(int64(i), 10) + ")" } return _RoundingMode_name[_RoundingMode_index[i]:_RoundingMode_index[i+1]]}
工具 stringer
拥有多个代码生成策略,取决于调用它的枚举值的性质。我们的案例是最简单的案例,其中包含“单次连续运行(single consecutive run)”的值。如果这些值形成多个连续运行,stringer
将生成稍微不同的代码,如果这些值根本不形成运行,则生成另一个版本。为了娱乐和讲解,详细研究 stringer
的来源;在这里,让我们关注当前使用的策略。
首先,_RoundingMode_name
常量用于有效地将所有字符串表示形式保存在单个连续字符串中。_RoundingMode_index
用作此字符串的查找表;例如 ToZero
值为 2。_RoundingMode_index[2]
是 26,所以该代码将索引_RoundingMode_name
在索引 26 中,这使我们的ToZero
部(端是下一个索引,32 在这种情况下) .
因此,代码将索引到索引 26 处的 _RoundingMode_name
,这将引导我们找到 ToZero
部分。
String()
中的代码有一个回调函数,以防添加更多枚举值但未重新运行 stringer
工具。在这种情况下,产生的值将是 RoundingMode(N)
,其中 N
是数值。
这个回调很有用,因为 Go 工具链中没有任何内容可以保证生成的代码与源代码保持同步;如前所述,运行生成器完全是开发人员的责任。
但是 func _()
中的奇怪代码呢?首先,请注意它实际上什么也没有编译:该函数不返回任何内容,没有副作用并且不会被调用。这个函数的目的是作为 编译守卫;如果原始 enum 以与生成的代码根本不兼容的方式发生变化,并且开发人员忘记重新运行 go generate
,则这是一种额外的安全性。具体来说,它将防止现有的枚举值被修改。在这种情况下,除非重新运行 go generate
,否则 String()
方法可能会成功,但会产生完全错误的值。编译守卫试图通过使代码无法编译越界数组查找来捕获这种情况。
现在让我们谈谈 stringer
的工作原理;首先,阅读它的 -help
是有指导意义的:
$ stringer -helpUsage of stringer: stringer [flags] -type T [directory] stringer [flags] -type T files... # Must be a single packageFor more information, see: https://pkg.go.dev/golang.org/x/tools/cmd/stringerFlags: -linecomment use line comment text as printed text when present -output string output file name; default srcdir/<type>_string.go -tags string comma-separated list of build tags to apply -trimprefix prefix trim the prefix from the generated constant names -type string comma-separated list of type names; must be set
我们已经使用 -type
参数告诉 stringer
为哪种类型生成 String()
方法。在现实的代码库中,人们可能希望在其中定义了多种类型的包上调用该工具;在这种情况下,我们可能希望stringer
只为特定类型生成 String()
方法。
我们没有指定 -output
flag,所以使用默认值;在这种情况下,生成的文件名为 roundingmode_string.go
。
眼尖的读者会注意到,当我们调用 stringer
时,我们没有指定它应该用作输入的文件。快速浏览该工具的源代码会发现它也不使用 GOFILE
环境变量。那么它如何知道要分析哪些文件呢?事实证明,stringer
使用 golang.org/x/tools/go/packages
从其当前工作目录(你还记得,这是包含 magic 注释的文件所在的目录)加载整个包。这意味着无论魔术(magic)注释在哪个文件中,stringer
默认情况下会分析整个包。如果你仔细考虑一下,这是有道理的,谁说常量必须与类型声明在同一个文件中?在 Go 中,文件只是一个方便的代码容器;包是工具关心的真正输入单位。
05 源码生成器和构建 tags
到目前为止,我们假设生成器在 go generate
运行时位于 PATH
中的某个位置,但情况并非总是如此。
考虑一个非常常见的场景,你的模块有自己的生成器,它只对这个特定的模块有用。当有人对模块进行黑客攻击时,他们能够克隆代码,运行 go generate
和 go build
等。但是,如果魔术注释假定生成器始终位于 PATH
中,则除非在运行 go generate
之前构建并正确指向生成器,否则这将无法工作。
Go 中的解决方案很简单,因为 go run
是运行生成器的完美搭配,这些生成器只是模块树中某处的 .go
文件。这里有[9]一个简单的例子。这是一个带有神奇注释的包文件:
package mypack//go:generate go run gen.go arg1 arg2func PackFunc() string { return "insourcegenerator/mypack.PackFunc"}
请注意此处如何调用生成器:使用 go run gen.go
。这意味着 go generate
将期望在与包含魔术注释的文件相同的目录中找到 gen.go
。gen.go
的内容是:
//go:build ignorepackage mainimport ( "fmt" "os")func main() { // ... same main() as the simple example at the top of the post}
它只是一个小的 Go 程序(在包 main
中)。唯一需要注意的是 //go:build
约束,它告诉 Go 工具链在构建项目时忽略这个文件。事实上,gen.go
不是包的一部分;它位于 main
包中,旨在与 go generate
一起运行,而不是编译到包中。
标准库中有许多小程序的示例,这些小程序旨在通过作为生成器的 go run
调用。
典型的模式是代码生成涉及 3 个文件,它们都共存于同一个目录/包中:
源文件包含一些包的代码,以及一条神奇的注释,用于调用带有 go run
的生成器。generator,它是一个单一的包含 package main
的.go
文件; 该生成器由源文件中的魔术注释中的go run
调用以生成生成的文件。生成器.go
文件通常会有一个//go:build ignore
约束,以将其从包本身的构建中排除。generated file 由 generator 生成; 在某些约定中,它与源文件具有相同的名称,但后跟 _gen
(如pack.go
-->pack_gen.go
);或者它可能是某种前缀(如gen
)。生成文件中的代码与源文件中的代码在同一个包中。在许多情况下,生成的文件包含一些未导出符号的实现细节;源文件可以在其代码中引用这一点,因为这两个文件位于同一个包中。
当然,这些都不是工具所要求的——它只是描述了一个通用的约定;特定的项目可以以不同的方式设置(例如,一个生成器为多个包生成代码)。
06 高级功能
本节讨论 go generate
的一些高级或较少使用的功能。
-command 标志
这个 flag 让我们为 go:generate
行定义别名;如果某些生成器是一个多字命令,我们想为多次调用缩短它,这可能会很有用。
最初的动机可能是将 go tool yacc
缩短为 yacc
:
//go:generate -command yacc go tool yacc
之后 yacc
可以只用这个 4 个字母的名字而不是三个词来调用多次。
有趣的是,go tool yacc
在 1.8 中[10]从核心 Go 工具链中删除了,而且我在主 Go 存储库(除了测试go generate
本身)或x/tools
模块中都没有发现 -command
的任何用法 。
-run 标志
该标志用于 go generate
命令本身,用于选择要运行的生成器。回想一下我们在同一个项目中调用了 3 次 samplegentool
的简单示例 。我们只能选择其中之一来使用 -run
标志运行:
$ go generate -run multi ./...Running samplegentool go on mypack.go cwd = /tmp/mymod/mypack os.Args = []string{"samplegentool", "arg1", "multiword arg"} GOARCH = amd64 GOOS = linux GOFILE = mypack.go GOLINE = 3 GOPACKAGE = mypack DOLLAR = $
这对于调试应该是显而易见的:在具有多个生成器的大型项目中,我们通常只想运行一个子集以进行调试/快速编辑这样的循环目的。
DOLLAR
在自动神奇地传递给生成器的环境变量( env var )中,有一个脱颖而出 —— DOLLAR
。它是做什么用的?为什么要将 env var 专用于一个字符?在 Go 源代码树中没有使用这个 env var。
DOLLAR
的起源可以追溯到Rob Pike 的这个提交[11]。正如更改描述所说,这里的动机是将 $
字符传递到生成器中,而无需复杂的shell escaping[12]。如果 go generate
调用 shell 脚本或将正则表达式作为参数的东西,这很有用。
可以使用我们的 samplegentool
生成器观察 DOLLAR
的效果。如果我们将其中一个神奇的注释更改为:
//go:generate samplegentool arg1 $somevar
生成器报告其参数为
os.Args = []string{"samplegentool", "arg1", ""}
这是因为 $somevar
被 shell 解释为引用 somevar
变量,该变量不存在,因此其默认值为空。相反,我们可以如下使用 DOLLAR
:
//go:generate samplegentool arg1 ${DOLLAR}somevar
然后生成器报告:
os.Args = []string{"samplegentool", "arg1", "$somevar"}
原文链接:https://eli.thegreenplace.net/2021/a-comprehensive-guide-to-go-generate/
参考资料
[1]C++模板元编程: https://en.wikipedia.org/wiki/Template_metaprogramming
[2]Go 1.4: https://go.dev/blog/generate
[3]samplegentool: https://github.com/eliben/code-for-blog/tree/master/2021/go-generate-guide/samplegentool
[4]mymod: https://github.com/eliben/code-for-blog/tree/master/2021/go-generate-guide/mymod
[5]官方文档: https://pkg.go.dev/cmd/go#hdr-Generate_Go_files_by_processing_source
[6]stringer: https://pkg.go.dev/golang.org/x/tools/cmd/stringer
[7]RoundingMode: https://pkg.go.dev/math/big#RoundingMode
[8]小示例模块中: https://github.com/eliben/code-for-blog/tree/master/2021/go-generate-guide/stringerusage
[9]这里有: https://github.com/eliben/code-for-blog/tree/master/2021/go-generate-guide/insourcegenerator
[10]在 1.8 中: https://tip.golang.org/doc/go1.8#tool_yacc
[11]Rob Pike 的这个提交: https://go-review.googlesource.com/c/go/+/8091/
[12]shell escaping: http://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#Quoting
往期推荐
欢迎关注「幽鬼」,像她一样做团队的核心。